Repository navigation
fix(cassandra): a Query of a partition with no items left answers no items - #611
Open
LeeroyHannigan wants to merge 7 commits into
Open
LeeroyHannigan wants to merge 7 commits into
LeeroyHannigan wants to merge 7 commits into
Conversation
DeleteItem sets the partition's static column partition_max_delete_timestamp. When the last item of a partition is deleted, Cassandra still answers a read of that partition with one row carrying only the static columns, with no item_data. Query parsed that row as an item and failed with `Column or Udt property 'item_data' is empty`, a 500, where Amazon DynamoDB answers Count 0 (measured 2026-10-26 at 06:48 UTC, every Query shape, twice). The GSI backfill's scan of the base table read the same row and failed the same way. Both now skip a row without item_data, as Scan already did.
Dual-target wire tests from the 2026-10-26 measurements: a Query of an emptied partition in every shape (plain, consistent, Limit 1, descending, COUNT, with a sort key condition) answers no items, as does an index Query; GetItem of a deleted item answers no Item; a partition with one item left answers it; and a GSI added by UpdateTable to a table with an emptied partition becomes ACTIVE and holds only the live item. On Cassandra before the fix the first five answered 500 and the index stayed CREATING.
A hash-only table stores the delete timestamp as a regular column, so it has no static-only row; Query, GetItem and Scan after deleting its item answer nothing on the service (measured 2026-10-26 at 08:00 UTC, twice) and on Cassandra before and after the fix. The test keeps that covered.
A TTL claim or a transaction prepare on an absent key writes a data row with only prepared_txn_id set and no item_data. Query asks Cassandra for LIMIT limit + 1 rows, so such rows take places in the read. Skipping them and then deciding "more pages" from the items kept returned a short page with no LastEvaluatedKey while items remained: a TTL table under concurrent writes to new keys returned 128 of 300 items in 9 of 1,162 full reads. On main the same rows answer 500. When rows were skipped and the read filled its LIMIT, Query now reads again with room for the skipped rows. The direct test writes such rows with CQL before, between and after the items and pages through the partition with every page size. The wire tests read with ConsistentRead where the shape allows, and the index test waits 120 s against extenddb instead of 1,500 s.
The previous commit read again with a LIMIT widened by the rows it had skipped, and the wider read applied the same test to itself. A row without an item stays inside every wider window, so one such row in front of a large partition made the Query read the whole partition one item per level: 1,501 Cassandra reads for a Limit 1 page over 1,500 items, and at 3,000 items the server overflowed its stack and aborted. Query now decides "more pages" from the rows Cassandra returned: a read that filled its LIMIT reports more pages, even when rows without an item leave the page short, and the engine reads on from the last item. Only a read in which no row holds an item is read again, with twice the LIMIT, so the reads grow with the run of such rows, not with the partition. The direct tests add a 3,000-item partition behind one such row and a run of 3,000 such rows in front of two items.
The index query test deleted the items right after putting them and then waited for the index to answer Count 0, which an index that had not yet received the puts answers too; a later descending query then saw an item on SQLite. The test now waits for the index to hold the three items, deletes them and waits for Count 0.
Adds the Query shapes measured on the service at 14:45 UTC to the emptied-partition wire file: the capacity such a Query consumes (0.5 units, 1.0 with ConsistentRead), an ExclusiveStartKey before, inside and after the deleted items, and partitions emptied by TransactWriteItems and by BatchWriteItem.
LeeroyHannigan
requested review from
amrith,
c33howard,
jcshepherd,
pdf-amzn,
robinnsc and
yesyayen
as code owners
October 26, 2026 15:21
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
On Cassandra, a Query of a partition whose items have all been deleted now answers no items. Before, it answered 500 InternalServerError, and a GSI added by UpdateTable to a table holding such a partition stayed
CREATING. A Query that reads other rows without an item, such as a TTL claim on a key being written, now answers the items around them instead of 500.This PR fixes no open issue.
What happened
DeleteItem sets the partition's static column
partition_max_delete_timestamp. When the last item of a partition is deleted, Cassandra still answers a read of that partition with one row that carries only the static columns and noitem_data. Query parsed that row as an item and failed withColumn or Udt property 'item_data' is empty(server log), answering 500. The GSI backfill, which scans the base table, read the same row, failed the same way, and retried for as long as the index existed.A TTL claim or a transaction prepare on an absent key writes a data row with only
prepared_txn_idset, also withoutitem_data. Query answered 500 on those rows too.A Query with a sort key condition or an ExclusiveStartKey, an index Query and a hash-only table do not see the static-only row: Cassandra returns it only when the read restricts no clustering column, an index Query reads the index table, and a hash-only table keeps the delete timestamp as a regular column of the item's own row.
What the service answers
Measured on 2026-10-26 at 06:48, 06:52, 06:59, 07:09, 08:00 and 14:45 UTC in us-east-1, each case twice with the same answer.
ConsistentRead,Limit1,ScanIndexForwardfalse,SelectCOUNT, a sort key condition) and on a global secondary index. GetItem of a deleted item answers no Item.SelectSPECIFIC_ATTRIBUTES, an ExclusiveStartKey before, inside or after the deleted items, and when the items were deleted by TransactWriteItems or BatchWriteItem. Such a Query consumes 0.5 capacity units, or 1.0 withConsistentRead.ACTIVE(after 520 s and 1,011 s) and answers the live item only.The change
crates/storage-cassandra/src/data/query.rs: a row withoutitem_datais skipped. Query asks Cassandra forLIMIT limit + 1rows, so skipped rows take places in the read. Query now reports more pages when the read filled its LIMIT, counting the rows Cassandra returned rather than the items kept, so a page that skipped rows can be short but carries a LastEvaluatedKey, and the engine reads on from its last item. A read in which no row holds an item has no item to continue from; Query reads that one again with twice the LIMIT, and doubles it again while every row read holds no item, so a run of R such rows costs about log2(R / Limit) extra reads.crates/storage-cassandra/src/data/ddl.rs: the GSI backfill skips such a row. It already counts raw rows for its page size and takes its cursor from the row'spk, which a static-only row carries.Tests
tests/test_query_emptied_partition.py: 17 dual-target wire tests built from the measurements. The index test waits up to 1,500 s for the index against the service, which took up to 1,011 s, and 120 s against extenddb.crates/storage-cassandra/tests/direct/query_rows_without_item.rs: 4 tests. One reads an emptied partition. The others write rows with onlyprepared_txn_idset (with CQL): before, between and after ten items, paged both ways with page sizes 1 to 5 and 9 to 11 and with no Limit; one such row in front of 3,000 items; and a run of 3,000 such rows in front of two items.Run locally against Cassandra 4.1 (one node, PasswordAuthenticator) on 2026-10-26:
query.rs(skip only), 3 fail (Limit 1 returns no item and no LastEvaluatedKey); withmain's, all 4 fail (500).cargo test -p extenddb-storage-cassandra --test direct_integration: 153 passed.--test ttl_integration: 26 passed, 1 ignored, andtest_ttl_enable_rejects_asynchronous_gsifails here withmain's storage code as well.devtools/run-testsapplies in CI: 17 passed at the head. Withmain's binary, 9 fail (the eight Query tests on a partition with no item left get 500, and the index added later staysCREATING) and 8 pass.main's binary on Cassandra answers 24 of them with 500.query,scan,delete,gsi,index,update_table,backfill,ttl,transact,batch,backup,export,restoreorlsi(652 tests): 38 fail at the head, and each of them fails onmaintoo, from the local single-node setup and the multi-attribute GSI gap below.query.rs, 9 of 1,162 reads ended at 128 items with no LastEvaluatedKey; onmain, 326 of 983 answered 500.prepared_txn_idset in front of 3,000 items: a Query with Limit 1 and with Limit 100 costs one Cassandra read (nodetool tablestats), as for the same partition without the row; with no Limit, 4 reads in both partitions.cargo +1.97.0 fmt --all -- --checkandcargo +1.97.0 clippy --all-targets -- -D warnings: clean.cargo test --workspace --exclude extenddb-storage-cassandra: 1,794 passed on stable and on 1.88.0.cargo test -p extenddb-storage-cassandra --lib: 61 passed.Not changed here
These answer the same on
main:item_dataafter Cassandra applies its LIMIT, and sets "more pages" from the items kept, so a Scan ends early with no LastEvaluatedKey after an emptied partition. 300 live single-item partitions and 30 emptied ones: a Scan with no Limit returns 119 items. CreateBackup, the TTL backfill and audit, ItemCount and TableSizeBytes, and export read through the same scan: 1,100 live items and 20 emptied partitions restore as 987 items.token(pk) > token(last pk), so a partition larger than its 500-row page loses its tail: a GSI added to a table with one 600-item partition holds 500 entries.Order by currently only supports the ordering of columns following their declared order in the PRIMARY KEY). fix(query): classify and order key conditions on a multi-attribute GSI the way the service does #429 changes this area.More than one restriction was found for the start bound).read_helpers.rs, which every backend uses, sets it; measured on Cassandra and SQLite. At 14:45 UTC the service answered such a page with no LastEvaluatedKey in 46 of 48 cases (partitions of one, two and three items, with and without deleted items, both read consistencies, both directions), and with a key and an empty next page in the other 2 (second round, descending, two deleted items before the live one).Merging
git merge-tree --write-treeagainst the 16 open PRs that changequery.rs,ddl.rsortests/direct_integration.rs: #527 and #395 add their own lines to themodlist oftests/direct_integration.rs, so each gains a conflict there (keep both sides); #395 conflicts withmainalready in other files, as do #588 and #474. The other 12 merge clean.